# frozen_string_literal: true

require 'spec_helper'

RSpec.describe Security::VulnerabilityReadsFinder, feature_category: :vulnerability_management do
  let_it_be(:project) { create(:project) }

  let_it_be(:low_severity_vuln_read) do
    create(:vulnerability, :with_finding, :with_issue_links, severity: :low, report_type: :sast, state: :detected,
      project: project).vulnerability_read
  end

  let_it_be(:high_severity_vuln_read) do
    create(:vulnerability, :with_finding, resolved_on_default_branch: true, severity: :high,
      report_type: :dependency_scanning, state: :confirmed, project: project).vulnerability_read
  end

  let_it_be(:medium_severity_vuln_read) do
    create(:vulnerability, :with_finding, severity: :medium, report_type: :dast, state: :dismissed,
      project: project).vulnerability_read
  end

  let_it_be(:dismissed_vulnerability) { create(:vulnerability, :dismissed, severity: :low, project: project) }
  let_it_be(:dismissed_vulnerability_read) do
    create(
      :vulnerability_read,
      :used_in_tests,
      state: dismissed_vulnerability.state,
      severity: dismissed_vulnerability.severity,
      vulnerability: dismissed_vulnerability,
      project: project
    )
  end

  let_it_be(:vulnerability_dismissed_without_reason) do
    create(:vulnerability, :dismissed, severity: :low, project: project)
  end

  let_it_be(:vulnerability_dismissed_without_reason_read) do
    create(
      :vulnerability_read,
      dismissal_reason: nil,
      state: vulnerability_dismissed_without_reason.state,
      severity: vulnerability_dismissed_without_reason.severity,
      vulnerability: vulnerability_dismissed_without_reason,
      project: project
    )
  end

  let(:filters) { {} }
  let(:vulnerable) { project }

  subject(:execute) { described_class.new(vulnerable, filters).execute }

  context 'when using the include_archived_projects param' do
    using RSpec::Parameterized::TableSyntax

    let(:archive_associated_vulns) { archived_project.vulnerabilities.map(&:vulnerability_read) }
    let(:not_archived_vulns) { project.vulnerabilities.map(&:vulnerability_read) }
    let(:all_vulns) { archive_associated_vulns + not_archived_vulns }
    let_it_be(:group) { create(:group) }

    let_it_be(:project) do
      create(:project, namespace: group).tap do |p|
        create(:vulnerability, :with_finding, project: p)
      end
    end

    let_it_be(:archived_project) do
      create(:project, :archived, namespace: group).tap do |p|
        create(:vulnerability, :with_finding, project: p)
      end
    end

    where(:vulnerable_object, :include_archived_projects, :result) do
      ref(:archived_project) | true   | ref(:archive_associated_vulns)
      ref(:archived_project) | false  | ref(:archive_associated_vulns)
      ref(:group)            | true   | ref(:all_vulns)
      ref(:group)            | false  | ref(:not_archived_vulns)
    end

    with_them do
      let(:vulnerable) { vulnerable_object }
      let(:filters) { super().merge(include_archived_projects: include_archived_projects) }

      it 'filters out vulnerabilities associated with archived projects as defined' do
        expect(subject).to eq(result)
      end
    end
  end

  context 'when not given a second argument' do
    subject { described_class.new(project).execute }

    it 'does not filter the vulnerability list' do
      expect(subject).to eq [high_severity_vuln_read, medium_severity_vuln_read,
        vulnerability_dismissed_without_reason_read, dismissed_vulnerability_read, low_severity_vuln_read]
    end
  end

  context 'when filtered by report type' do
    let(:filters) { { report_type: %w[sast dast] } }

    it 'only returns vulnerabilities matching the given report types' do
      is_expected.to contain_exactly(low_severity_vuln_read, medium_severity_vuln_read,
        vulnerability_dismissed_without_reason_read, dismissed_vulnerability_read)
    end
  end

  context 'when filtered by severity' do
    let(:filters) { { severity: %w[medium high] } }

    it 'only returns vulnerabilities matching the given severities' do
      is_expected.to contain_exactly(medium_severity_vuln_read, high_severity_vuln_read)
    end
  end

  context 'when filtered by state' do
    let(:filters) { { state: %w[detected confirmed] } }

    it 'only returns vulnerabilities matching the given states' do
      is_expected.to contain_exactly(low_severity_vuln_read, high_severity_vuln_read)
    end

    context 'when filtered only by dismissal reason' do
      let(:used_in_tests) { Vulnerabilities::Read.dismissal_reasons[:used_in_tests] }
      let(:filters) { { dismissal_reason: [used_in_tests] } }

      it 'returns only vulnerabilities dismissed with used in tests reason' do
        is_expected.to contain_exactly(dismissed_vulnerability_read)
      end
    end

    context 'when filtered only by state: dismissed' do
      let(:filters) { { state: %w[dismissed] } }

      it 'returns all dismissed vulnerabilities' do
        is_expected.to contain_exactly(medium_severity_vuln_read, vulnerability_dismissed_without_reason_read,
          dismissed_vulnerability_read)
      end
    end

    context 'when filtered by dismissal reason and other states' do
      let(:used_in_tests) { Vulnerabilities::Read.dismissal_reasons[:used_in_tests] }
      let(:filters) { { state: %w[confirmed dismissed], dismissal_reason: [used_in_tests] } }

      it 'returns dismissed vulnerabilities and those matching other states' do
        is_expected.to contain_exactly(high_severity_vuln_read, medium_severity_vuln_read,
          vulnerability_dismissed_without_reason_read, dismissed_vulnerability_read)
      end
    end
  end

  context 'when filtered by scanner external ID' do
    let(:filters) do
      { scanner: [low_severity_vuln_read.vulnerability.finding_scanner_external_id,
        high_severity_vuln_read.vulnerability.finding_scanner_external_id] }
    end

    it 'only returns vulnerabilities matching the given scanner IDs' do
      is_expected.to contain_exactly(low_severity_vuln_read, high_severity_vuln_read)
    end
  end

  context 'when filtered by scanner_id' do
    let(:filters) do
      { scanner_id: [low_severity_vuln_read.vulnerability.finding_scanner_id,
        medium_severity_vuln_read.vulnerability.finding_scanner_id] }
    end

    it 'only returns vulnerabilities matching the given scanner IDs' do
      is_expected.to contain_exactly(low_severity_vuln_read, medium_severity_vuln_read)
    end
  end

  context 'when vulnerable is a Group' do
    let(:another_project) { create(:project, namespace: group) }
    let(:vulnerable) { group }

    let!(:another_vulnerability) { create(:vulnerability, :with_finding, project: another_project) }

    let_it_be(:group) { create(:group) }
    let_it_be(:archived_project) { create(:project, :archived, namespace: group) }

    before do
      project.update!(namespace: group)
    end

    context 'when filtered by project' do
      let!(:archived_vulnerability) { create(:vulnerability, :with_findings, project: archived_project) }
      let(:filters) { { project_id: [another_project.id, archived_project.id] } }

      it 'only returns vulnerabilities matching the given projects' do
        is_expected.to contain_exactly(another_vulnerability.vulnerability_read)
      end

      context 'when including archived projects' do
        let(:filters) { super().merge(include_archived_projects: true) }

        it 'returns vulnerabilities matching the given projects' do
          is_expected.to contain_exactly(another_vulnerability.vulnerability_read, archived_vulnerability.vulnerability_read)
        end
      end
    end
  end

  context 'when sorted' do
    let(:filters) { { sort: method } }

    context 'when sort method is not given' do
      let(:method) { nil }

      it {
        is_expected.to eq [high_severity_vuln_read, medium_severity_vuln_read, vulnerability_dismissed_without_reason_read,
          dismissed_vulnerability_read, low_severity_vuln_read]
      }
    end

    context 'ascending by severity' do
      let(:method) { :severity_asc }

      it {
        is_expected.to eq [vulnerability_dismissed_without_reason_read, dismissed_vulnerability_read, low_severity_vuln_read,
          medium_severity_vuln_read, high_severity_vuln_read]
      }
    end

    context 'descending by severity' do
      let(:method) { :severity_desc }

      it {
        is_expected.to eq [high_severity_vuln_read, medium_severity_vuln_read, vulnerability_dismissed_without_reason_read,
          dismissed_vulnerability_read, low_severity_vuln_read]
      }
    end

    context 'ascending by detected_at' do
      let(:method) { :detected_asc }

      it {
        is_expected.to eq [low_severity_vuln_read, high_severity_vuln_read, medium_severity_vuln_read,
          dismissed_vulnerability_read, vulnerability_dismissed_without_reason_read]
      }
    end

    context 'descending by detected_at' do
      let(:method) { :detected_desc }

      it {
        is_expected.to eq [vulnerability_dismissed_without_reason_read, dismissed_vulnerability_read,
          medium_severity_vuln_read, high_severity_vuln_read, low_severity_vuln_read]
      }
    end
  end

  context 'when filtered by has_issues argument' do
    let(:filters) { { has_issues: has_issues } }

    context 'when has_issues is set to true' do
      let(:has_issues) { true }

      it 'only returns vulnerabilities that have issues' do
        is_expected.to contain_exactly(low_severity_vuln_read)
      end
    end

    context 'when has_issues is set to false' do
      let(:has_issues) { false }

      it 'only returns vulnerabilities that does not have issues' do
        is_expected.to contain_exactly(high_severity_vuln_read, medium_severity_vuln_read,
          vulnerability_dismissed_without_reason_read, dismissed_vulnerability_read)
      end
    end
  end

  context 'when filtered by has_resolution argument' do
    let(:filters) { { has_resolution: has_resolution } }

    context 'when has_resolution is set to true' do
      let(:has_resolution) { true }

      it 'only returns vulnerabilities that have resolution' do
        is_expected.to contain_exactly(high_severity_vuln_read)
      end
    end

    context 'when has_resolution is set to false' do
      let(:has_resolution) { false }

      it 'only returns vulnerabilities that do not have resolution' do
        is_expected.to contain_exactly(low_severity_vuln_read, medium_severity_vuln_read,
          vulnerability_dismissed_without_reason_read, dismissed_vulnerability_read)
      end
    end
  end

  context 'when filtered by more than one property' do
    let_it_be(:read4) do
      create(:vulnerability, :with_finding, severity: :medium, report_type: :sast, state: :detected,
        project: project).vulnerability_read
    end

    let(:filters) { { report_type: %w[sast], severity: %w[medium] } }

    it 'only returns vulnerabilities matching all of the given filters' do
      is_expected.to contain_exactly(read4)
    end
  end

  context 'when filtered by image' do
    let(:vulnerable) { project }
    let_it_be(:cluster_vulnerability) { create(:vulnerability, :cluster_image_scanning, project: project) }
    let_it_be(:finding) do
      create(:vulnerabilities_finding, :with_cluster_image_scanning_scanning_metadata, project: project,
        vulnerability: cluster_vulnerability)
    end

    let(:filters) { { image: [finding.location['image']] } }

    it 'only returns vulnerabilities matching the given image' do
      is_expected.to contain_exactly(cluster_vulnerability.vulnerability_read)
    end

    context 'when different report_type is passed' do
      let(:filters) { { report_type: %w[dast], image: [finding.location['image']] } }

      it 'returns an empty relation' do
        is_expected.to be_empty
      end
    end

    context 'when vulnerable is InstanceSecurityDashboard' do
      let(:vulnerable) { InstanceSecurityDashboard.new(project.users.first) }

      it 'does not include cluster vulnerability' do
        is_expected.not_to contain_exactly(cluster_vulnerability.vulnerability_read)
      end
    end
  end

  context 'when filtered by cluster_agent_id' do
    let_it_be(:cluster_agent) { create(:cluster_agent, project: project) }
    let_it_be(:cluster_vulnerability) { create(:vulnerability, :cluster_image_scanning, project: project) }
    let_it_be(:finding) do
      create(:vulnerabilities_finding, :with_cluster_image_scanning_scanning_metadata, agent_id: cluster_agent.id.to_s,
        project: project, vulnerability: cluster_vulnerability)
    end

    let(:filters) { { cluster_agent_id: [finding.location['kubernetes_resource']['agent_id'].to_i] } }

    it 'only returns vulnerabilities matching the given agent_id' do
      is_expected.to contain_exactly(cluster_vulnerability.vulnerability_read)
    end

    context 'when different report_type is passed' do
      let(:filters) do
        { report_type: %w[dast], cluster_agent_id: [finding.location['kubernetes_resource']['agent_id'].to_i] }
      end

      it 'returns empty list' do
        is_expected.to be_empty
      end
    end
  end

  describe 'use of unnested filters' do
    let_it_be(:group) { create(:group) }
    let_it_be(:project) { create(:project) }

    let(:mock_relation) { Vulnerabilities::Read.none }

    before do
      allow(vulnerable).to receive(:vulnerability_reads).and_return(mock_relation)
      allow(mock_relation).to receive(:use_unnested_filters).and_call_original
    end

    context 'when the given vulnerable is a project' do
      let(:vulnerable) { project }

      it 'calls `use_unnested_filters` on relation' do
        execute

        expect(mock_relation).to have_received(:use_unnested_filters)
      end
    end

    context 'when the given vulnerable is a group' do
      let(:vulnerable) { group }

      it 'calls `use_unnested_filters` on relation' do
        execute

        expect(mock_relation).to have_received(:use_unnested_filters)
      end
    end

    context 'when the given vulnerable is an instance security dashboard' do
      let(:vulnerable) { InstanceSecurityDashboard.new(project.users.first) }

      it 'does not call `use_unnested_filters` on relation' do
        execute

        expect(mock_relation).not_to have_received(:use_unnested_filters)
      end
    end
  end
end
